Skip to content

fix: tighten recursive delete policy matching#3251

Open
Peldan wants to merge 9 commits into
microsoft:mainfrom
Peldan:fix/recursive-delete-policy
Open

fix: tighten recursive delete policy matching#3251
Peldan wants to merge 9 commits into
microsoft:mainfrom
Peldan:fix/recursive-delete-policy

Conversation

@Peldan

@Peldan Peldan commented Jul 3, 2026

Copy link
Copy Markdown

Description

The recursive-delete deny rule depended too much on narrow command regexes such as rm -rf. Before this change, equivalent recursive deletes like rm -fr important-data, rm -r -f important-data, rm --recursive important-data, Remove-Item -Recurse important-data, and rd /s /q important-data could miss the deny rule and fall through to the normal shell-tool review path instead.

One root cause was that several old rules matched text shape rather than command semantics. In the Claude Code, OpenCode, and Copilot CLI bundles the Bash rule used patterns like \brm\b[\s\S]*\b-rf\b; in JavaScript regex semantics \b-rf\b does not match rm -rf target (the space and - are both non-word characters), so the intended deny never fired for the canonical form. Antigravity's old pattern matched compact rm -rf/rm -fr but missed split/reordered forms (rm -r -f, rm --force --recursive, rm target -rf).

This PR reduces the regex to a broad delete-command trigger and delegates the deny decision to a hasRecursiveDelete() parser that inspects the actual flags, so equivalent destructive forms are denied consistently while safe cleanup targets are not.

Deny semantics: a delete is denied when it is recursive, regardless of whether a force flag is present, unless every target is a known safe-cleanup directory (node_modules, dist, build, …). Force is still parsed but is not required to deny. Non-recursive deletes (rm -f, rm -i, rm --force) are not hard-denied by this rule.

Notable cases covered:

  • rm -rf, rm -fr, rm -rfv, reordered rm important-data -rf
  • split/long forms: rm -r -f, rm --recursive, rm --recursive --force (either order)
  • recursive-without-force: rm -r, rm --recursive, Remove-Item -Recurse
  • PowerShell: Remove-Item -Recurse -Force, abbreviations Remove-Item -r -fo, ri -r -fo
  • Windows: rd /s /q, del … /s /q
  • delimiter-wrapped invocations: `rm -rf /`, {rm -rf /;}, $(rm -rf /)
  • short-option clusters with unanticipated letters: rm -rfx foo
  • not denied: safe targets (rm -rf node_modules, Remove-Item -Recurse -Force build), and non-recursive deletes (rm -f, rm -i)

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • Security fix

Package(s) Affected

  • agent-governance

Checklist

  • I have added tests that prove my fix/feature works

Attribution & Prior Art

  • This contribution does not contain code copied or derived from other projects without attribution
  • Any external projects that inspired this design are credited in code comments or documentation
  • If this PR implements functionality similar to an existing open-source project, I have listed it below

AI Assistance

  • I can explain every meaningful change in this PR: what it does, why, and what tradeoffs were considered
  • I have run tests and verification appropriate for this change
  • No part of this PR was autonomously submitted by an AI agent without my review
  • I have not used AI to generate review comments on others' PRs

If AI tools materially shaped this change, briefly note what was used:
Codex helped add regression tests, find the same parser issue in sibling packages, and carry the reviewed fix across those packages. I reviewed the implementation and test coverage.

IP, Patents, and Licensing

  • This contribution does not implement patent-pending or patent-encumbered techniques
  • This contribution does not require an NDA or licensing agreement to understand or use
  • Any AI tools used have terms compatible with the MIT License

Peldan added 6 commits July 3, 2026 15:00
Signed-off-by: arvidpeldan <peldans@gmail.com>
Signed-off-by: arvidpeldan <peldans@gmail.com>
Signed-off-by: arvidpeldan <peldans@gmail.com>
Signed-off-by: arvidpeldan <peldans@gmail.com>
Signed-off-by: arvidpeldan <peldans@gmail.com>
Signed-off-by: arvidpeldan <peldans@gmail.com>
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

PR Review Summary

Check Status Details
🔍 Code Review ⚠️ Missing No current-run comment
🛡️ Security Scan ⚠️ Missing No current-run comment
🔄 Breaking Changes ⚠️ Missing No current-run comment
📝 Docs Sync ⚠️ Missing No current-run comment
🧪 Test Coverage ⚠️ Missing No current-run comment

Verdict: ⚠️ AI review incomplete; ready for human review

AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims.

@github-actions github-actions Bot added the size/XL Extra large PR (500+ lines) label Jul 3, 2026
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
🤖 AI Agent: contributor-guide — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

Welcome, and thank you for your contribution! 🎉

Your detailed explanation of the problem and solution is excellent, and the added test coverage is thorough.

Before merging, please ensure:

  1. The updated regex patterns are tested against edge cases to avoid unintended matches or false positives.
  2. Verify that the new helper functions (e.g., getRmCommandDetails) are covered by unit tests for all expected scenarios.

For guidance, please refer to CONTRIBUTING.md.

@Peldan

Peldan commented Jul 3, 2026

Copy link
Copy Markdown
Author

The branch is split by surface so each package change is easy to review on its own. The first commits apply the recursive-delete fix and matching tests to OpenCode, Claude Code, Copilot CLI, and Antigravity CLI separately. There’s also a small examples commit to keep the Copilot CLI example policies in sync with the package configs.

The final commit is a review follow-up across the affected parsers. It tightens the flag handling for longer Unix clusters like rm -rfv, preserves PowerShell abbreviations like -r -fo, and avoids false positives for ordinary parameters such as -Confirm and -Filter.

@Peldan

Peldan commented Jul 4, 2026

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="Regent"

@Peldan Peldan marked this pull request as ready for review July 4, 2026 08:41
imran-siddique
imran-siddique previously approved these changes Jul 7, 2026

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed across all four bundles (claude-code, opencode, copilot-cli, antigravity) plus the examples/copilot-cli-agt config mirror. The design is sound and fail-safe: the regex is reduced to a broad delete-command trigger (^|[\s;&|()])(?:rm|remove-item|...)\b and the actual deny decision is delegated to the new hasRecursiveForceDelete() parser. shouldBypassBlockedCommandRule now denies only when the command is genuinely recursive and force AND is not a safe-cleanup target.

I worked through the notable edge cases in getRmCommandDetails:

  • Split / reordered / clustered short options (rm -r -f, rm -fr, rm -rfv, rm target -rf), long options (--recursive --force in either order), PowerShell -Recurse/-Force and their unambiguous abbreviations (-r, -fo), and Windows rd /s /q all resolve to recursive+force → deny.
  • -f alone is correctly gated out as force (the PowerShell force check requires length >= 2), and node_modules/build/dist, rm -f, rm --recursive alone, -Confirm/-Filter correctly fall through as not-denied.

Importantly this does not introduce a fail-open: a destructive form the parser fails to classify reverts to the normal shell-tool review path rather than being auto-allowed, and the leading (^|[\s;&|()]) anchor prevents substring false-matches (charm, warm). The PR also correctly identifies the pre-existing \b-rf\b word-boundary bug that meant the canonical rm -rf target never fired in several bundles — this genuinely fixes that gap.

Tests are comprehensive (positive deny + negative allow lists) and mirrored per package; CI green. Careful, well-scoped security hardening. LGTM.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regressions vs the previous denylist:

  • Left-anchor: (^|[\s;&|()]) misses `rm -rf /` and {rm -rf /;} which the old \b caught. Add ` and { to the class in all 14 JSONs.
  • Recursive-without-force is now allowed on antigravity/copilot (a test asserts rm --recursive important-data passes). That is a loosening; restore the deny or own it explicitly in the title/body.
  • isUnixRmShortOptionCluster fails open on unknown letters (rm -rfI foo slips through). Invert to single-dash-letters-only.
  • Restore the trailing newline in agent-governance-opencode/lib/policy.mjs.

Follow-up to the recursive-delete hardening, addressing review feedback
on PR microsoft#3251. Applied to all four bundles (claude-code, opencode,
copilot-cli, antigravity) plus the examples config mirror.

- Trigger anchor: add ` and { to the delete-command character class in
  all 14 policy JSONs so `rm -rf /` and {rm -rf /;} are matched, which
  the previous class dropped. Also add normalizeCommandNameToken() so the
  parser un-glues leading `, {, (, $ from the command token; without it a
  matched trigger still bypassed the deny (commandIndex === -1).

- Recursive-without-force: restore the deny. hasRecursiveForceDelete ->
  hasRecursiveDelete now gates on recursive alone (force is still parsed
  but no longer required), so `rm --recursive`, `rm -r`, and
  `Remove-Item -Recurse` are denied again unless the target is a
  safe-cleanup dir.

- Short-option cluster: invert isUnixRmShortOptionCluster from the
  allow-list /^-[dfiprvw]+$/ to letters-only /^-[a-z]+$/, so a cluster
  with an unanticipated letter (e.g. `rm -rfx foo`) no longer fails open
  and drops its r/f flags.

- Restore the trailing newline in agent-governance-opencode/lib/policy.mjs.

Tests updated per bundle: recursive-only and delimiter-wrapped forms moved
to the deny lists, a genuine fail-open cluster (-rfx) added as a
regression guard, and the artificial bash -Confirm negative replaced with
`rm -i` (PowerShell -Confirm/-Filter negatives kept under their proper
surface). All four suites pass (npm run check, exit 0).

Signed-off-by: arvidpeldan <peldans@gmail.com>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: test-generator — `policy.mjs`

AI-generated review output. Treat it as untrusted analysis and verify before acting.

policy.mjs

  • test_shouldBypassBlockedCommandRule_handles_recursive_delete -- Validate that recursive deletes are correctly denied based on the new hasRecursiveDelete logic.
  • test_getRmCommandDetails_parses_recursive_and_force_flags -- Ensure getRmCommandDetails correctly identifies recursive and force flags across various command formats.
  • test_isSafeCleanupCommand_handles_complex_cases -- Test isSafeCleanupCommand with edge cases like delimiter-wrapped commands or mixed safe/unsafe targets.
  • test_isUnixRmShortOptionCluster_handles_unexpected_flags -- Verify that unrecognized short-option clusters fail safe without bypassing recursive/force detection.

policy-engine.test.mjs

  • test_evaluatePreToolUse_denies_recursive_without_force -- Add cases to confirm denial of recursive deletes even when the force flag is absent.
  • test_withPackagedPolicyState_handles_customized_policies -- Validate that policy customization and temporary state setup work as expected for recursive delete scenarios.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: code-reviewer — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

TL;DR: 0 blockers, 1 warning. The PR improves recursive delete detection but has a minor test coverage gap.

# Sev Issue Where
1 Warn Test coverage for edge cases like rm -rfx is incomplete policy-engine.test.mjs

Action items: None.

Warnings:

  1. Test coverage for edge cases like rm -rfx is incomplete. Fine as follow-up PRs.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: breaking-change-detector — API Compatibility

AI-generated review output. Treat it as untrusted analysis and verify before acting.

API Compatibility

Severity Change Impact
High The function shouldBypassBlockedCommandRule now requires an additional parameter toolName. This is a breaking change for any code that calls this function without the new parameter.
High The function isSafeCleanupCommand now requires an additional parameter toolName. This is a breaking change for any code that calls this function without the new parameter.
High The function getRmCommandDetails has been introduced, and its behavior is now integral to the logic of isSafeCleanupCommand and hasRecursiveDelete. This may break any code relying on the previous implementation of isSafeCleanupCommand or similar logic.
High The function hasRecursiveDelete has been introduced and is now used in place of isSafeCleanupCommand in certain contexts. This changes the behavior of the API and may break existing integrations that rely on the old logic.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: docs-sync-checker — Docs Sync

AI-generated review output. Treat it as untrusted analysis and verify before acting.

Docs Sync

  • shouldBypassBlockedCommandRule() in policy.mjs -- missing docstring
  • getRmCommandDetails() in policy.mjs -- missing docstring
  • isSafeCleanupCommand() in policy.mjs -- missing updated docstring for new toolName parameter
  • hasRecursiveDelete() in policy.mjs -- missing docstring
  • isPowerShellTool() in policy.mjs -- missing docstring
  • isPowerShellRecursiveParameter() in policy.mjs -- missing docstring
  • isPowerShellForceParameter() in policy.mjs -- missing docstring
  • isUnixRmShortOptionCluster() in policy.mjs -- missing docstring
  • normalizeCommandNameToken() in policy.mjs -- missing docstring
  • README.md -- section on policy configuration needs update to reflect the new recursive delete handling logic
  • CHANGELOG.md -- missing entry for the updated recursive delete deny rule logic and its expanded coverage

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: security-scanner — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

No security issues found.

@Peldan

Peldan commented Jul 7, 2026

Copy link
Copy Markdown
Author

Regressions vs the previous denylist:

  • Left-anchor: (^|[\s;&|()]) misses `rm -rf /` and {rm -rf /;} which the old \b caught. Add ` and { to the class in all 14 JSONs.
  • Recursive-without-force is now allowed on antigravity/copilot (a test asserts rm --recursive important-data passes). That is a loosening; restore the deny or own it explicitly in the title/body.
  • isUnixRmShortOptionCluster fails open on unknown letters (rm -rfI foo slips through). Invert to single-dash-letters-only.
  • Restore the trailing newline in agent-governance-opencode/lib/policy.mjs.

Thanks! All four addressed in bb6f6c8:

  1. Left-anchor: Added ` and { to the trigger class in all 14 JSONs. This alone wasn't enough, though: the parser's tokenizer kept `rm/{rm/$(rm as one glued token, so a matched trigger still bypassed the deny (commandIndex === -1). Added normalizeCommandNameToken() to strip leading ` { ( $, so `rm -rf /`, {rm -rf /;}, and $(rm -rf /) are now actually denied.
  2. Recursive-without-force: Restored the deny. hasRecursiveForceDeletehasRecursiveDelete gates on recursive alone now, so rm --recursive / rm -r / Remove-Item -Recurse are denied unless the target is a safe-cleanup dir. Applied to all four bundles for consistency, and the PR description now states this explicitly.
  3. Cluster fails open: Inverted /^-[dfiprvw]+$//^-[a-z]+$/. Small note: -rfI was actually already caught (i is in the old set), so the real fail-open is a letter outside it! The regression test uses rm -rfx foo, which slipped before and denies now.
  4. Trailing newline: Restored in agent-governance-opencode/lib/policy.mjs.

Peldan and others added 2 commits July 7, 2026 13:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR (500+ lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants